Skip to content

fix(sync): converge channel sidebar state across devices on the same identity - #6525

Open
wpfleger96 wants to merge 22 commits into
mainfrom
wpfleger/channel-sections-sync-fixes
Open

fix(sync): converge channel sidebar state across devices on the same identity#6525
wpfleger96 wants to merge 22 commits into
mainfrom
wpfleger/channel-sections-sync-fixes

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 21, 2026

Copy link
Copy Markdown
Member

Channel sidebar state (sections, sort, stars, mutes) diverges between two clients on the same identity — e.g. a dev build and an installed DMG: sections differ at app open, sometimes self-heal after minutes, sometimes only after a manual "kick" edit. The causes are client-side gaps in the desktop sync managers. Client-only; no relay or database changes.

Two payload shapes, each converged with the model that fits it: sections and sort are whole-blob last-write-wins; stars and mutes are per-entry sets converged by max-merge. All four lanes share one publish-timestamp clamp (clampPublishCreatedAt) so a skewed remote head can never wedge a lane's future publishes.

Durable multi-window outbox (all four lanes)

Unpublished edits persist to localStorage and survive quit or community switch inside the publish debounce. localStorage has no atomic read-modify-write, so records are per-window and write-once (<prefix>:<pubkey>:<relay>:<nonce>:<seq>): a new edit writes a new key, then deletes the window's own older ones — no window can overwrite or stale-clear a peer's pending edit. Foreign records are reclaimed only against durable relay evidence (strict created_at supersession for whole-blob lanes, head subsumption for merge lanes) and only after replay. The mutable legacy shared key from older builds is never deleted; whole-blob lanes replay it once per value via a consumption marker written only after the intent is durably transferred into a v2 key.

Sections and sort (whole-blob LWW)

  • A local edit that loses LWW adopts the winning remote head (write-through to state and storage) instead of silently republishing over it. The decision uses a per-edit baseline frozen at queue time, generation-CAS single-flight publishes, and an exact-id fold so an ambiguously-ACKed own write is recognized rather than adopted away.
  • Same-second ties order by the relay's rule (created_at DESC, id ASC).
  • An unreadable head (decrypt/parse/version failure) or a failed pre-publish fetch retains the durable pending edit and retries — the manager never publishes over state it could not actually read.
  • Publish OK is not proof of retention (the relay OKs a superseded NIP-33 write as a no-op): after OK the lane fetches the authoritative head and clears/folds the baseline only on an exact event-id match; a different readable head is adopted, an unreadable or own-prior head retains and retries — so a same-second collision loser never records a phantom head that erases its next edit.
  • Reconnect re-drives the existing generation, preserving its frozen baseline, so a remote that won while the edit was pending is adopted, not published over.
  • A 60s reconcile loop plus visibility refresh heals divergence without waiting for a reconnect event. Sort gains the full durable lane sections has (it previously dropped pending edits on destroy() and never retried failures).

Stars and mutes (per-entry Lamport-rev max-merge)

  • Each entry carries an additive rev (missing ⇒ 0; payload stays version: 1 so old builds keep parsing). One commutative, associative, idempotent mergeStores (updatedAtrev → true-leaf) runs on every observation path, replacing the previous ownership/dirty-set/supersession machinery.
  • A click stamps updatedAt = max(now, observed) and mints rev = maxSeen + 1, so it dominates everything this build has observed for that channel. rev is rejected at or above Number.MAX_SAFE_INTEGER (exclusive, so maxSeen + 1 always stays safe) and updatedAt requires a safe integer, so a malformed huge value can never wedge later toggles.
  • The pre-publish read is tri-state: only a successful fetch proving the head absent publishes the local store directly; a thrown fetch or unreadable head retains and retries — a max-merge is only safe once both operands were read.
  • The store is keyed per (pubkey, relay) like sections and sort, so preferences from one community never bleed into another — in particular a non-empty store from relay A can no longer seed-publish onto a first-visited relay B. Legacy pubkey-only data migrates once on first scoped read; migrated data is exposed to publish only after the scoped write and legacy delete both succeed and the legacy key is confirmed gone — on a storage failure the read returns an empty store and rolls back the partial scoped copy only when the legacy key provably still holds importable data; if the legacy key is already gone (or unreadable) the scoped copy is preserved as the sole surviving copy, and a read-time gate keeps any unproven copy hidden until the legacy key is confirmed gone, leaving the migration retryable so no relay can import legacy prefs the legacy key still holds.
  • Publish OK is not proof of retention (a superseded NIP-33 write also gets OK): the outbox clears only once a fetched retained head subsumes what was written; otherwise merge and retry.

Accepted residuals

Documented in-source; each is bounded and self-healing: a same-second old-build click loses to a new-build rev ≥ 1 until the next later-second click; an old build cannot reverse a future-stamped entry until its clock catches up; a >500-same-second-entry eviction window can deterministically lose one click; truly concurrent whole-blob edits resolve by LWW — the guarantee is that no window's publish erases another's unpublished intent, not a merge of concurrent blobs; at most one lingering legacy key and marker per lane per (pubkey, relay); and if both the legacy-key delete and the failure-path probe throw, the preserved scoped copy plus a later community switch after partial storage recovery can carry the legacy value into two relay scopes — preferred deliberately over the alternative of permanently losing the only surviving copy.

@wpfleger96
wpfleger96 requested a review from a team as a code owner August 21, 2026 23:12
@wpfleger96
wpfleger96 force-pushed the wpfleger/channel-sections-sync-fixes branch from 3da6677 to d0c23c9 Compare August 24, 2026 19:34
@wpfleger96 wpfleger96 changed the title fix(sync): converge channel sections across devices on the same identity fix(sync): converge channel sidebar state across devices on the same identity Aug 25, 2026
Duncan and others added 8 commits August 26, 2026 10:54
Channel-section sidebar state diverged between a user's devices and
sometimes never self-healed. Four client-side gaps fed the divergence:

- A local edit that lost whole-blob LWW was silently republished as remote
  content while the UI kept showing the edit. Now the manager adopts the
  winning remote head (writes it through to state + storage, advances the
  watermark) and skips publishing, unifying with the relay's OK-false
  conflict path as one convergence mechanism.
- Edits made inside the 2s publish debounce were dropped on quit or
  community switch. A durable localStorage outbox persists every edit
  synchronously and resumes it on next mount; adopt clears the outbox so a
  superseded edit can never be replayed.
- A skewed remote head could push the published createdAt past the relay's
  future-drift window and wedge all later publishes. createdAt is now
  clamped inside that window.
- Stale-at-open state waited for a reconnect that a healthy socket never
  fires. A reconciliation loop periodically refetches the head (steady 60s,
  backoff on failure) and refreshes on window visibility.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Three cross-layer races defeated the one-convergence-mechanism design:

- An older in-flight publish unconditionally cleared pending state on
  completion, erasing a newer edit queued mid-flight. Each pending edit now
  carries a monotonic generation; a completion clears pending/outbox/retry only
  via compare-and-swap on the generation it published.
- Hook-level remote application (bootstrap/live/periodic) cancelled the pending
  publish's timers without deciding supersession, stranding the durable outbox
  and clobbering the optimistic edit. applyRemote now defers entirely to a
  pending edit, whose own debounced publish converges via publish-or-adopt; the
  manager's adopt path clears pending before write-through so the winning remote
  still applies.
- The equal-timestamp tie-break kept the largest event id, opposite the
  relay/database canonical order (created_at DESC, id ASC → lowest id wins).
  applyRemote now applies a strictly-lower id and ignores ids >= the last
  applied, so the UI converges on the event the relay actually stored.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
useChannelStars, useChannelMutes, and useChannelSortPreference carried the
same inverted equal-timestamp comparator as channel sections: applyRemote kept
the largest event id, opposite the relay/database canonical order (created_at
DESC, id ASC -> lowest id wins). Two devices writing the same second could
leave the UI showing an event the relay did not store.

Apply a strictly-lower id and ignore ids >= the last applied, matching the
sections fix and the relay winner across all four 30078 sidebar surfaces. Each
hook gains a regression test: larger-then-lower id delivery at equal timestamp,
lower-id store wins (mutation-checked - reverting >= to <= fails each).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Two convergence holes one layer under the pass-1 fixes:

Sections: the pre-publish head check compared the fetched head against
the mutable lastRemoteCreatedAt, which a live event observed during the
debounce window already advanced to that same head — equality fell
through to publish and the local blob overwrote a remote that became
head after the edit was queued. Freeze a canonical head baseline
(created_at, id) at publishSections and compare the fetched head against
that generation baseline instead, adopting when the head advanced.

Stars/mutes: applyRemote admits the canonical lower-id winner but then
mergeStores resolved equal per-entry updatedAt as local/prev-wins, so a
stale larger-id value delivered first survived and undid the winner. Add
mergeApplyingRemote which resolves an entry-timestamp tie toward the
canonical incoming blob while keeping strictly-newer local entries.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Each prior round patched one cross-generation interleaving and opened
another a layer deeper. Kill the race class structurally instead.

Sections: serialize publish cycles (one in-flight at a time; a newer edit
queued mid-cycle defers and the completion re-drives it). The per-edit
pre-publish baseline is frozen at queue time, so a genuine remote observed
during the debounce window still adopts, while our own accepted head is
folded forward via canonicalMax so a stale generation's own write is never
mistaken for a competing remote and adopted away. Dual generation guards in
doPublish (post-fetch and pre-publish) stop a stale generation signing or
publishing after a newer edit exists.

Stars/mutes: scope mergeApplyingRemote (remote-wins on entry-tie) and the
pending-publish cancel to fire only on a canonical supersession of an
already-applied same-timestamp larger-id head. Every other application
(bootstrap/live/newer-timestamp) keeps local-wins mergeStores and does not
cancel the pending publish, so a later same-second local click is no longer
clobbered by an older remote entry that decrypts late.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…biguous-ACK heads

Round-4 client-side convergence fixes for channel-sections/stars/mutes sync,
closing two silent edit-loss variants that survived publish serialization.

Ambiguous-ACK fold: a publish whose ACK is lost may still have been accepted
by the relay. Retain each attempt's signed id; when a later cycle's pre-publish
fetch returns a head whose id matches a prior attempt, fold it forward as our
own accepted predecessor and publish above it instead of adopting it away and
erasing the queued edit. A head the relay never accepted can never surface by
id, so the fold is proof-gated on an exact id match.

Canonical-supersession dirty overlay (stars + mutes): a lower-id canonical
correction that arrives after a same-second local click must not clobber the
click. Apply the correction to the prior remote layer, then overlay entries
changed locally since that layer; never cancel a pending publish merely because
a correction arrived.

Client-only: loss discovery relies on the existing pre-publish fetch, live
subscription, and reconcile loop rather than a relay conflict signal.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Replace the LWW register plus ownership/dirty-set/canonical-supersession
machinery with a per-entry Lamport `rev` and a single max-merge on every
path, mirroring the read-state data model. Each entry carries an additive
optional `rev` (missing implies 0; payload stays `version: 1` so older
builds keep parsing our blobs). One `mergeStores` orders by
updatedAt then rev then the starred/muted-true leaf, and ends in the
500-entry bound.

Clicks stamp `updatedAt = max(now, localEntry?.updatedAt ?? 0,
maxUpdatedAtSeen(id))` and mint `rev = max(localEntry.rev, maxRevSeen(id))
+ 1`, so a click strictly dominates every state its replica has observed
and cannot lose to a same-second remote. The sync managers hold a
per-channel two-field high-water map fed by a single `observe()` on every
ingest path. Stars sync keeps the generation-CAS + single-flight lane and
bounded-backoff retry plus a durable outbox so an in-flight publish can
never clear a newer pending edit; mutes sync mirrors it. Remote ingestion
never touches the pending lane. Deleted: mergeApplyingRemote,
mergeStoresWithTie, mergeCanonicalSupersession, dirtyChannelIds,
lastAppliedRemoteTs/lastAppliedEventId, and the event-clock branch.

Sections and sort are untouched.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… contract

The hard-eviction-branch test asserted only the rev tuple outcome on two
one-entry stores while its comment claimed the >500-entry eviction/remount
setup. Build the real fixture: >500 equal-updatedAt entries so the bound
evicts the target by the id tiebreak, then a remounted rev-1 click merged
against the retained rev-100 remote, asserting the deterministic rev-100
outcome in both merge orders. Stars and mutes.

The unobserved-future mixed-fleet residual was stated but never exercised
directly: the fast-clock suites cover only the observed-future fix. Add a
click with an empty high-water at t, then a genuinely unobserved
opposite-value head at t+300 that wins on the primary updatedAt key. Both
hook suites.

Test-only; no production source changes.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Requesting changes at exact head 0d797b550e405eea9557e053f884533ad3dd891a. The relay ordering and the per-entry max-merge are coherent, but the lifecycle still has reproducible edit-loss paths.

[P1] The durable outboxes are not safe across desktop windows

Sections, stars, and mutes share one localStorage outbox key per identity + relay, but each window owns only an in-memory generation. A write in one window therefore replaces another window's pending payload, and a completion in either window removes the shared key without proving that it still owns the persisted value.

For sections, window A can start publishing edit A, window B can replace the outbox with newer edit B, and A's ACK then clears B's outbox. If B closes before its debounce fires, the next bootstrap applies relay head A before discovering there is no outbox, so B is permanently lost. See channelSectionsStorage.ts:211-245, channelSectionsSync.ts:262-281,284-313,522, and useChannelSections.ts:138-156.

Stars and mutes can lose independent clicks even earlier: two windows starting from the same store can click different channels before receiving each other's asynchronous storage event. Their whole-store main/outbox writes race, teardown cancels both timers, and remount resumes only the last blob. The storage handler merges only into React state; it does not durably merge the main store or outbox. See channelStarsStorage.ts:206-242, useChannelStars.ts:57-72,95-113, and channelStarsSync.ts:256-260,383-393 (mutes mirror these paths).

Please give persisted attempts cross-window ownership, not only manager-local generations. A per-operation outbox whose owner deletes only its own record, or an actual cross-window serialization mechanism, would close both overwrite and stale-clear races. Add multi-window tests that interleave write, ACK, storage delivery, teardown, and remount.

[P1] Sort preferences still drop pending edits on ordinary lifecycle and failure paths

publishSortPrefs keeps intent only in memory (channelSortSync.ts:113-121). destroy() deliberately cancels and discards it (:252-262), while a failed publish only logs and never retries (:167-210). A live remote also cancels the debounce while leaving pendingStore stranded (useChannelSortPreference.ts:82-103).

Reproduction: change a sort mode and quit/switch communities within two seconds, or let one publish time out and remount. Bootstrap then whole-blob-replaces the local cache with the relay head (useChannelSortPreference.ts:108-122), visibly reverting the user's choice. Please carry the durable outbox, generation ownership, serialized retry, and pending-aware remote application used by sections over to sort, with lifecycle tests.

[P2] Future relay heads can wedge stars, mutes, and sort publishing

Sections clamps created_at inside the relay's future-drift window (channelSectionsSync.ts:463-472), but stars (channelStarsSync.ts:298-301), mutes (matching code), and sort (channelSortSync.ts:183-186) stamp lastRemoteCreatedAt + 1 without a cap. If a self-authored head was accepted near the relay's +900s boundary, a correctly clocked second device emits +901s; the relay rejects it, and retries continue deriving from the same head until wall time catches up. Apply the same bounded timestamp rule across all four sidebar sync surfaces.

CI is green at this head. I did not duplicate the CI-equivalent suite locally; these failures are source-reproduced interleavings absent from the current tests.

@wpfleger96
wpfleger96 force-pushed the wpfleger/channel-sections-sync-fixes branch from 0d797b5 to 16aeab6 Compare August 26, 2026 14:55

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Re-reviewed exact current head 16aeab68b094baaba508a0f2fc91c06b0c430c1c against base ef0d2025683869418e8eee22ac5b5ac16c5198b7 after the force-rebase. Changes are still required.

All 19 files in this PR have the same Git blob IDs as at previously reviewed head 0d797b550e405eea9557e053f884533ad3dd891a, including all production sync files and tests. The only sidebar differences between the old and new repository trees are three UI files inherited from the new base; they do not touch the NIP-78 sync producers, consumers, or their direct dependencies. Consequently, the previous review's blockers survive unchanged:

  1. P1: sections/stars/mutes still lack cross-window ownership for the shared durable outboxes. Manager-local generation fencing cannot stop one desktop window from overwriting another window's persisted payload or from unconditionally clearing the newer payload after its own older ACK/no-op/adopt completion. The existing tests remain single-manager and do not cover two windows sharing the key.
  2. P1: sort still drops pending intent. It still has only an in-memory pendingStore, no durable outbox or failure retry, destroy() still cancels and nulls the edit, and a live remote can still cancel the timer while leaving the intent stranded.
  3. P2: stars, mutes, and sort still derive created_at = max(now, lastRemoteCreatedAt + 1) without the future-drift clamp used by sections. A self-authored head accepted near the relay's +900s limit can therefore wedge subsequent writes until wall time catches up.

Please address the concrete reproductions and repair boundaries in the review on 0d797b550: #6525 (review)

Current CI is not green: Desktop Smoke E2E (2) failed at this head while several desktop jobs remain in progress. I am not using that still-unclassified failure as a separate code finding; the source blockers above independently require changes.

Duncan and others added 2 commits August 26, 2026 11:35
Carl's re-review found three edit-loss paths remaining after the
stars/mutes rev-merge landed.

[P2] Stars, mutes, and sort stamped createdAt = lastRemoteCreatedAt + 1
uncapped, so a self-authored head accepted near the relay's +900s drift
boundary wedged every later publish until wall time caught up. Extract
the sections clamp into a shared clampPublishCreatedAt in
sidebarSyncWatermark.ts (all four surfaces already import that module)
and wire sections/stars/mutes/sort to it.

[P1] Sort preferences never got the durable lane: doPublish only logged
on failure, destroy() discarded the pending edit, and a live remote
cancelled the debounce with the edit stranded. Port the sections lane —
durable outbox + bootstrap resume, generation/CAS ownership, single-
flight + completion re-drive, 2s->30s backoff, 60s reconcile loop, and
pending-aware remote application. Sort stays whole-blob LWW; a lost head
is adopted at pre-publish rather than republished.

Tests: a clamp test per surface, and sort lifecycle coverage — outbox
resume after destroy-inside-debounce, retry without a later edit,
overlapping-generation safety, live-remote-during-debounce adopt, and a
hook-level pending-defer test.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sections, stars, mutes, and sort each persist an unpublished edit under one
localStorage outbox key per identity+relay, but generation ownership is only
in-memory per window. Without cross-window ownership, one window's completing
publish could clear a peer window's still-unpublished edit, and (on the merge
lanes) a peer's write could overwrite an edit before it published.

Every outbox write now mints an ownership token and stores a {store, token}
envelope; a completing publish compare-and-clears only when the stored token
still matches its own, so a peer's newer write survives an older window's ACK.
Stars and mutes additionally read-merge-write both the durable outbox and the
main store via their per-entry mergeStores, so two windows editing different
channels both survive; sort and sections replace whole-blob with LWW resolution
matching the relay. Sort also gains the durable outbox + bounded retry it
previously lacked, and stars/mutes/sort now clamp publish created_at inside the
relay's future-drift window like sections. The envelope reader tolerates a
legacy token-less entry so an outbox written by a prior build still resumes.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Requesting changes at exact head 9141fe292268e2681e65664bbeb708b732af2535.

[P1] The multi-window outbox operations are still racy

The new ownership token does not make the localStorage operations atomic. clearOutboxEntry reads and validates the stored token, then calls removeItem separately (sidebarSyncWatermark.ts:141-162). Window A can read token A, window B can write its newer {store, tokenB} envelope, and A can then remove B's value using the stale read. That still loses an unpublished edit on quit/remount.

Stars and mutes have the matching write-side race: writeChannelStarsOutbox reads the shared entry, merges in memory, then writes separately (channelStarsStorage.ts:226-237; mutes mirrors it). If two windows read before either writes, each computes a one-sided merge and the later setItem drops the other pending click. The main-store read/merge/write has the same shape (channelStarsStorage.ts:135-144).

The added multi-window tests execute whole operations sequentially, such as A write, B write, then A clear (multiWindowOutbox.test.mjs:62-167), so they cannot exercise either read/write or read/remove interleaving. localStorage provides no compare-and-delete or transactional read-modify-write primitive; the token proves what was read, not what is still stored at the destructive operation.

Please move this durability boundary to a design that does not depend on atomicity localStorage lacks, such as per-operation append-only records with owner-specific deletion, or an appropriately serialized cross-window store. Add tests that pause operations between their read and write/remove steps and verify teardown/remount preserves every unpublished intent.

The LWW comparator itself matches the relay's created_at DESC, id ASC rule; this review is blocking only on the durability race above. I reviewed read-only GitHub metadata and diff and did not check out or execute PR code.

localStorage has no atomic compare-and-delete or transactional
read-modify-write, so a single outbox key shared across every window
could not be mutated safely: one window's read-then-write or
read-then-remove races a peer's write in the gap and drops its
still-unpublished edit. A per-write ownership token narrowed that window
but could not close it — the token proves what was read, not what is
still stored at the destructive op.

Key the outbox per window instead: <prefix>:<pubkey>:<relay>:<nonce>,
where the nonce is minted once and parked in sessionStorage. Each window
is the sole writer of its own key, so a hot-path write is one
unconditional setItem — the write race is designed out, not guarded.
Resume enumerates every window's key: merge lanes (stars/mutes) fold all
records order-independently; whole-blob lanes (sort/sections) replay the
max-queuedAt record with a nonce tiebreak. Redundant foreign keys are
reclaimed at boot, gated on durable relay evidence (merge: head subsumes;
whole-blob: head created_at supersedes) and re-read immediately before
removal so a live peer's fresh write in the recheck gap survives.
Reclamation runs only on a successful head fetch, never on a failed one.
The token contract is deleted entirely — ownership is the key.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ce-free

The per-window token/recheck reclamation still performed a non-atomic
compare-then-delete on a mutable foreign key: a live owner could rewrite
its key between the reclaim decision-read and the removeItem, and the
whole-blob `queuedAt <= head.created_at` gate dropped same-second and
legacy `queuedAt=0` records that had not provably lost LWW.

Records are now write-once: a key is `<prefix>:<pubkey>:<relay>:<nonce>:<seq>`
and is never rewritten. A new edit writes a new key (next zero-padded seq)
then deletes its own older keys (write-before-delete, so a crash leaves at
least one record). Foreign reclamation reads an immutable record, proves it
reclaimable against durable relay evidence, and deletes it with no recheck.
Whole-blob supersession is strict (`queuedAt < head.created_at`); replay runs
before reclamation in every hook so a same-second record is consumed into
pending first; the legacy v1 shared key is only ever replayed, never deleted.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The legacy v1 shared outbox key is never deleted (it is mutable and a
concurrently-live old build may still rewrite it), so the whole-blob
resume path re-read it on every boot and republished the stale blob above
the current relay head forever. A found relay head never stopped it: a
fresh manager has no lastPublishedStore and queueing the replay freezes
publishBaseline to the just-fetched head.

Distinguish compatibility replay from permanently pending intent with a
durable per-value consumption marker, whole-blob lanes only. resumeWholeBlobOutbox
excludes the legacy record when its exact raw matches the stored marker;
a live old build rewriting the key stores a different raw and is replayed
again. The hook transfers the intent into its own v2 key (synchronous
publish) BEFORE writing the marker, so a crash in that gap replays the
blob once more rather than losing it.

Merge lanes (stars/mutes) need no marker but gained a head-subsumed gate
so a lingering legacy key does not re-drive an identical boot-time publish.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Requesting changes at exact head 3712e6fb2c71f03587a476c36081b1ac831a60c7 against base ef0d2025683869418e8eee22ac5b5ac16c5198b7.

Product direction: the relay is authoritative, while sidebar sections/sort use whole-blob LWW and stars/mutes use per-entry max-merge. The compatibility contract is that all four lanes converge across concurrently running builds/windows without dropping an unpublished user edit. I reviewed the four surfaces across bootstrap, live delivery, storage events, reconnect, periodic/visibility reconciliation, local edits, publish failure/ACK, destroy, remount, and community switch. I found these blockers:

[P1] Preserve the original whole-blob baseline when reconnect wakes a pending edit

useChannelSections.ts:264-276 and useChannelSortPreference.ts:257-269 fetch on reconnect and then call the public publish*() method for an already-pending edit. Those methods increment pendingGeneration and reset publishBaseline to the now-current lastRemoteHead (channelSectionsSync.ts:277-295, channelSortSync.ts:273-289).

Reproduction: window A queues an edit against H0; window B publishes H1 before A reconnects. A's reconnect fetch records H1, and applyRemote correctly defers because A is pending. The reconnect handler then re-queues A's old store, replacing its frozen H0 baseline with H1. The pre-publish fetch sees equality rather than remoteAdvancedSince, so A publishes above H1 instead of adopting the remote winner. This reverses the intended LWW decision on both whole-blob lanes.

Wake/retry the existing generation without resetting its baseline, and cover remote advancement plus reconnect while an edit is pending for both sections and sort.

[P1] Do not clear a merge-lane outbox merely because its EVENT received OK

channelStarsSync.ts:279-330 and channelMutesSync.ts:279-330 clear their own durable outbox after publishEvent resolves. But the relay returns accepted OK for a superseded NIP-33 write as a no-op, and two windows can also prefetch the same head and publish different whole blobs at the same second. Only one blob is retained.

The shared cache does not close this race: writeChannelStarsStore / writeChannelMutesStore at storage lines 140-151 overwrite the shared key without reading it, while the storage handlers at useChannelStars.ts:67-74 and useChannelMutes.ts:67-74 ignore e.newValue and reread whichever snapshot currently occupies that key. Two windows can therefore create different-channel snapshots from the same base, overwrite the cache before either storage event is processed, and independently receive successful OKs. The non-retained window then deletes the only durable record of its click. The relay, cache, and all outboxes can end with that edit absent.

After publication, clear an own outbox only after an authoritative retained-head fetch proves that head subsumes the attempted store; otherwise merge and retry. Add a two-window test where distinct edits race from the same head and both OK paths complete in either NIP-33 order.

[P1] A later old-build click can lose forever to an earlier new-build rev in the same second

channelStarsStorage.ts:59-77,188-195 and the identical mutes code normalize a missing rev from an old build to 0, then rank updatedAt before rev. If a new build publishes {updatedAt:T, rev:1, starred:true}, an old build observes it and the user unstars during the same second T, that later old-build payload has no rev and parses as rev:0. New builds deterministically retain the earlier true/rev:1; the user's later action cannot win until another click occurs in a later second.

Keeping payload version: 1 allows parsing but does not make the causal model backward-compatible. Use an explicit migration/version strategy that handles old writers, or define and test a product-accepted mixed-fleet limitation rather than claiming observed later intent cannot be lost.

[P1] An unreadable current head must block whole-blob overwrite, not fall through to publish

channelSectionsSync.ts:353-359 and channelSortSync.ts:341-347 record an existing head, but if decryption or parsing fails they return publish. A pending local blob is then signed at lastRemoteCreatedAt + 1 and replaces the only current head, even though the client could not inspect it or determine whether it should be adopted. A future schema, transient keychain/decrypt fault, or malformed payload therefore becomes destructive data loss on the next local edit.

Treat an unreadable/future head as a failed precondition: retain the durable pending edit and retry/surface the incompatibility, but do not overwrite unknown authoritative state. Add pre-publish coverage for decryption failure and unsupported payload version in sections and sort.

…sidual

Close the pass-3 review blocker and Carl's four P1s on the channel
sections/sort/stars/mutes relay sync, all in the sidebar lib:

- Gate the legacy-consumed marker on a proven v2 transfer. writeOwnOutbox
  now returns whether the fresh key's setItem succeeded; the whole-blob
  hooks write the marker only when durable, so a quota failure leaves the
  legacy record replayable instead of silently suppressing the only copy.
- Reconnect wakes the existing pending edit (retryPendingPublish) rather
  than re-queueing via publish*(), which reset the frozen baseline and
  published a stale edit over an advanced remote.
- Merge lanes clear an own outbox only after an authoritative retained-head
  fetch proves the head subsumes the attempted store; a bare EVENT OK on a
  superseded NIP-33 write no longer drops the loser's durable click.
- An unreadable/unsupported pre-publish head retains the pending edit and
  retries rather than overwriting state the client could not inspect.
- Document the accepted mixed-fleet residual (a same-second old-build click
  reads rev 0 and loses to an earlier new-build rev, healing on the next
  later-second click) and pin it with a test; no protocol change.

Regressions added for each item, mutation-verified where the fix lives in
the hook.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

REQUEST CHANGES on exact head 6078943f3be3a3fea99e86d225b79246af53f599 against ef0d2025683869418e8eee22ac5b5ac16c5198b7. I reviewed GitHub metadata, diff, and exact-head source only; I did not execute PR code.

Product contract: the relay is authoritative. Sections/sort converge as whole-blob LWW; stars/mutes converge by per-entry max-merge. A client must not overwrite relay state it could not read, because that turns a transient fetch/decrypt failure into durable cross-device data loss.

P1 — Retain edits when the authoritative pre-publish read fails

All four lanes fall through to publishing local state when the pre-publish relay query throws. Sections does so at channelSectionsSync.ts:371-416 and sort has the matching branch; stars does so at channelStarsSync.ts:210-232 and mutes mirrors it. The client therefore cannot distinguish “no head exists” from timeout/auth/socket failure.

Reproduction: device B publishes a newer sections/sort blob or independent star/mute entries after device A’s last observation; A edits while its pre-publish fetch fails. A signs above its stale watermark and can replace the unseen authoritative head, erasing B’s data. Durable outboxes and retries do not recover content that A never merged.

Return a tri-state publish decision in every lane: only a successful, genuinely absent head may publish the local store directly; fetch failure must retain the outbox and retry. Add causal tests for an unseen newer head plus rejected pre-publish fetch across sections, sort, stars, and mutes.

P1 — Stars and mutes also overwrite an existing but unreadable head

channelStarsSync.ts:220-231 records an event but returns the local store when decryption, JSON parsing, or schema validation fails; doPublish then publishes it (:315-357). Mutes has the identical path. This can destroy entries in a temporarily undecryptable or future-schema head. Bootstrap already classifies this condition as failed, while sections/sort correctly return retain for it.

Give merge-lane pre-publish reads the same retain/retry behavior and cover decrypt failure, malformed JSON, and unsupported schema. A max-merge is safe only after both operands were actually read. Dungeon law is unhelpfully strict about this.

P2 — Reject numeric revisions that cannot advance

channelStarsStorage.ts:59-69 and the matching mutes parser accept any finite non-negative integer rev, including values beyond Number.MAX_SAFE_INTEGER. Local clicks mint maxRev + 1 (useChannelStars.ts:252-267, mirrored for mutes); at sufficiently large IEEE-754 values that no longer increases. With equal timestamps/revisions, true wins (channelStarsStorage.ts:199-207), so a malformed starred:true or muted:true entry can suppress later unstar/unmute attempts indefinitely.

Require safe, bounded integers for revision and timestamp inputs, define malformed-input handling, and add a regression proving a huge revision cannot wedge a later false toggle.

The prior blockers around multi-window ownership, reconnect baselines, unreadable whole-blob heads, publish retention confirmation, and timestamp clamping are materially addressed. Exact-head CI is broadly green; it does not cover the failed-read transitions above.

Extends the retain-on-unreadable pattern to the two remaining overwrite
paths and hardens the rev/timestamp parse boundary.

- All four lanes tri-state the pre-publish read: a THROWN fetch (timeout /
  auth / socket) now retains and retries instead of falling through to
  publish, so an edit during a transient outage can no longer sign above a
  stale watermark and erase an unseen newer head.
- The merge lanes (stars/mutes) retain when the existing head fails
  decryption/JSON/schema parsing rather than publishing the local store
  over it — a max-merge is only safe once both operands were read, matching
  the whole-blob lanes.
- The rev/timestamp parsers require Number.isSafeInteger: an unsafe rev is
  normalized to 0 and an unsafe timestamp entry is dropped, so a malformed
  huge-rev true entry can no longer wedge later false toggles forever.

Retain reuses the existing bounded-backoff retry and generation CAS, so a
later readable/successful head resumes normal resolution. Regressions cover
all four lanes for the failed-fetch and unreadable-head paths and the
rev-wedge P2 case in both storage suites.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…adroom

The rev parse bound used Number.isSafeInteger, which accepts
Number.MAX_SAFE_INTEGER itself. The click path mints rev = maxRev + 1,
so an accepted boundary rev overflows to an unsafe value that never
advances again — recreating the same-second toggle wedge the bound
exists to prevent. Reject rev >= Number.MAX_SAFE_INTEGER (exclusive,
normalize to 0) so maxRev + 1 always stays safe. updatedAt keeps its
inclusive bound: the click path takes Math.max(now, observed), never
increments, so it cannot overflow.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

REQUEST CHANGES on exact head 8bbc11b340cc584975af8f44da0036e0b9bbddc3 against base ef0d2025683869418e8eee22ac5b5ac16c5198b7. I reviewed GitHub metadata, diff, exact-head source, and the relay replacement path only; I did not execute PR code.

Product contract: the relay is authoritative within its community. Sections/sort converge as whole-blob LWW; stars/mutes converge by per-entry max-merge. Local caches and accepted publish responses must not fabricate a retained head or carry one community's preferences into another.

P1 — Confirm retention before committing sections/sort publishes

Both whole-blob managers treat any resolved publishEvent as proof their event became the retained head: sections unconditionally records the attempted tuple, clears ambiguousAttemptIds, folds it into publishBaseline, and discards the outbox at desktop/src/features/sidebar/lib/channelSectionsSync.ts:529-559; sort mirrors this at channelSortSync.ts:486-511.

That premise is false for parameterized replaceable events. At this exact head, the DB classifies a same-or-older canonical tuple as Superseded (crates/buzz-db/src/replaceable.rs:238-251), and the relay deliberately maps that to PersistResult::Duplicate rather than a rejection (crates/buzz-relay/src/handlers/command_executor.rs:161-171). The client therefore sees a successful publish promise even though its event was not retained.

Reproduction: two windows fetch head H, then both stamp H.created_at + 1; the lower event ID wins, while the higher-ID loser also receives success. The loser records its nonexistent event as the baseline and drops its durable edit. If that user edits again before reconciliation, the pre-publish fetch returns the actual same-second lower-ID winner; remoteAdvancedSince() treats it as a new competing head, and adoptRemote() discards the second edit too. One collision can erase both the raced edit and the following user action.

After ACK, fetch the authoritative retained head. Only clear/fold the attempt when that exact ID is retained; otherwise keep/retry the durable intent or adopt the true winner without poisoning the next edit's baseline. Add a two-manager same-second collision test where the losing manager makes another edit.

P1 — Scope the stars/mutes primary cache to the relay

The new outboxes are relay-scoped, but the main stars and mutes stores remain pubkey-only: channelStarsStorage.ts:32-34,91-101,147-155 and the matching channelMutesStorage.ts use buzz-channel-…:<pubkey>. Their hooks reload that same cache after every relayUrl change (useChannelStars.ts:46-57,103-107; mutes mirrors it).

Reproduction: use relay A and persist non-empty stars/mutes, then switch the same identity to relay B for the first time. B's bootstrap successfully finds no head, its B-scoped watermark is zero, and runBootstrap seed-publishes the non-empty “local” store (sidebarSyncWatermark.ts:609-615). Those entries came from A, so A's community preferences are copied into B. Sections and sort already avoid this by including normalized relayUrl in their primary cache keys.

Scope every stars/mutes primary-cache read, write, and storage-event key by normalized relay. Define a deliberate one-time policy for the legacy pubkey-only cache rather than importing it into every newly visited relay, and add an A→B absent-head regression proving no seed leak.

Exact-head CI is broadly green; the Codex security review was cancelled. These source-reproduced state transitions are not covered by that result.

…utes

Carl r6 raised two P1 correctness gaps.

Whole-blob lanes (sections, sort) treated a resolved publishEvent as proof
of retention, but the relay OKs a superseded NIP-33 write as a no-op
(Duplicate). Two windows stamping the same created_at both get OK while
only the lower event id is retained, so the loser recorded a nonexistent
head as its baseline and its next edit adopted the true winner away. After
ACK the lanes now fetch the authoritative head and only clear/fold/discard
on an exact event-id match; a different readable head is adopted, an
unprovable/unreadable/own-prior head is retained and retried.

The stars/mutes primary cache was pubkey-only while its outbox was
relay-scoped, so a first visit to relay B seed-published relay A's
preferences. The cache key is now relay-scoped like sections/sort, with a
one-time legacy migration that deletes the pubkey-only key so no other
relay can import it.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…s gone

Thufir's r6 review found the legacy→scoped migration exposed legacy data
to bootstrap even when the scoped write threw or the legacy delete did not
take, wrapped in a swallowed try/catch. On any storage failure the legacy
key survived while the relay still received the legacy preferences and
could seed-publish them; a second relay later read the same legacy key and
seeded it too — so "globally one-time" held only on the happy path.

migrateLegacyStore now exposes migrated data only when ownership is proven:
write the scoped key, delete the legacy key, and confirm the delete took.
On a scoped-write throw, a legacy-delete throw, or a delete that silently
does not take, roll back the partial scoped copy and return DEFAULT_STORE,
leaving the migration retryable. An empty store cannot seed-publish
(bootstrap's non-empty-local gate), so DEFAULT is safe.

The identical flaw existed in all three lanes with a legacy primary-cache
migration — sections (pre-existing on main), stars, and mutes. Sort has no
such migration and is untouched. Concurrent claimants are safe: every
window resolves the same app-wide active community, so at any instant they
scope to the same relay and any race migrates the identical value to the
identical key; a second relay only enters play after a community switch,
which is strictly after the first scoped read consumed the legacy key.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The migration wrote the scoped key before deleting the legacy key. If the
legacy delete threw and the rollback delete also threw, the scoped copy
survived and the next read gave it precedence, exposing legacy prefs to
seed-publish while the legacy key was still importable by another relay.

Move the ownership check into the reader: a scoped copy is exposed only
while no importable legacy key remains, so a half-migrated copy can never
be read as owned. The claim completes inline once storage recovers.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ly present

The pass-3 catch rolled back the just-written scoped copy on any failure.
If the legacy delete succeeded but the confirmation read then threw, legacy
was already gone, so the rollback destroyed the only surviving copy —
permanent preference loss.

Roll back only when the legacy key is provably still importable (keeping the
scoped copy then would let a second relay scope double-import it). If legacy
is gone, the scoped copy is the sole copy and is kept; if the probe itself
throws, keep it too, favoring no-data-loss. The read-time ownership gate
keeps a kept-but-unproven copy hidden while legacy remains, so it never seeds
early. Residual: legacy-delete and catch-probe both throw, then a relay
switch after partial recovery can let both scopes carry the value — bounded
and deliberately preferred over data loss.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes's GitHub account.

Changes requested

[P1] Preserve a newer edit when retained-head confirmation finds a same-second winner

Both whole-blob managers record the just-ACKed attempted tuple before awaiting authoritative confirmation (channelSectionsSync.ts:598-607, mirrored in channelSortSync.ts:555-564). A user edit queued during that await freezes the attempted tuple as its baseline (channelSectionsSync.ts:316-328, sort :312-322). If confirmation then returns a peer's lower-id same-second winner, adoptRemote records that winner but its generation guard leaves the newer edit's frozen baseline unchanged (channelSectionsSync.ts:294-301, sort :290-297). On the next cycle, remoteAdvancedSince compares winner {t,a} against attempted {t,z} and adopts the newer user edit away because a < z (channelSectionsSync.ts:414-427, sort :397-409). The outbox is cleared without ever publishing that edit.

This is reachable when a relay ACKs a superseded parameterized-replaceable write as a no-op, confirmation is delayed, and the user edits again before confirmation resolves. The collision tests queue the next edit only after confirmation/adoption has completed, so they do not exercise this interleaving.

Please fold a confirmed retained winner into the current generation's baseline when the newer edit was queued after the ACKed attempt became observable, while preserving the generation CAS for pending/UI state. Add deterministic sections and sort tests that pause confirmation, queue the second edit after ACK, then return the lower-id winner; the second edit must publish above that winner and remain durable until its own retention is confirmed.

The prior exact-head blockers around relay-scoped stars/mutes storage and non-overlapping retained-head confirmation appear addressed. This remaining race can still silently lose a sidebar sections or sort edit, so it is blocking.

Duncan and others added 2 commits August 28, 2026 16:31
… mid-confirmation

When a second edit is queued after a publish ACK but before
confirmRetainedHead resolves, pendingGeneration bumps while the first
edit's confirmation fetch is in flight. The second edit's publishBaseline
is frozen against the attempted (non-retained) event id. When confirmation
returns the peer's same-second lower-id winner via adoptRemote with stale
gen, the phantom baseline causes the second edit's pre-publish check to see
the winner as having advanced past our attempt and adopt it away — the
user's second edit is silently lost (Carl r6 P1).

Extract foldSupersedingAttemptWinner (shared helper, both managers): when
publishBaseline.eventId is one of our own attempted ids and the remote is
the same-second lower-id peer winner, fold the winner into the baseline.
The pre-publish check then sees equality and publishes above the true
retained head. Scoped to same-second lower-id only — a strictly-later
remote is a genuine advance that the pending edit must still adopt
(pass-2 invariant). Invoke from adoptRemote stale-gen path (confirmation
variant) and from fetchOwnBlobBeforePublish (confirmation-retain/retry
variant). Both managers (sections, sort) mirrored.

Add one regression per manager: pauses the confirmation fetch, queues the
second edit during the pause, then releases with the peer winner. Asserts
the second edit publishes above the winner and is confirmed retained rather
than adopted away. Mutation: reverting the adoptRemote fold fails the test.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…dd regressions

Track attempt id → generation in ambiguousAttemptIds (Map<string,number>)
instead of a plain Set. foldSupersedingAttemptWinner now requires that the
mapped generation is strictly less than pendingGeneration, making it
explicit that the fold only applies when a newer edit's baseline was
poisoned by an older generation's unconfirmed attempt — not when the
current generation's own in-flight attempt is the baseline.

The generation invariant is structurally guaranteed (publishBaseline is
frozen before the attempt id is known), but the explicit guard provides
defense in depth against future code changes and makes the constraint
self-documenting.

Add two regressions per lane (sections + sort):
- Single-edit confirmation-retain path: ACK resolves, confirmation
  returns retain, retry sees peer winner → adopt, do not republish.
  Companion test proving correct adopt behavior in the no-second-edit path.
- Queued-during-confirmation: second edit queued after ACK but before
  confirmRetainedHead resolves; stale-gen adoptRemote folds winner into
  baseline; second edit's pre-publish check publishes above true head.
  Mutation: reverting adoptRemote fold fails this test.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants